第 9 章:rebase 衍合多個分支修改
在 Git 中整合來自不同分支的修改主要有兩種方法:整合 merge 以及衍合 rebase。
衍合的基本操作
開發任務分叉到兩個不同分支,又各自提交了更新。
)
之前介紹過,整合分支最容易的方法是 merge 命令。 它會把兩個分支的最新快照(C3 和 C4)以及二者最近的共同祖先(C2)進行三方合併,合併的結果是生成一個新的快照(並提交)。
)
其實,還有一種方法:你可以提取在 C4 中引入的補丁和修改,然後在 C3 的基礎上應用一次。 在 Git 中,這種操作就叫做 衍合(rebase)。 你可以使用 rebase 命令將提交到某一分支上的所有修改都移至另一分支上,就好像“重新播放”一樣。
在這個例子中,你可以檢出 experiment 分支,然後將它衍合到 master 分支上
git checkout experiment
git rebase master
First, rewinding head to replay your work on top of it...
Applying: added staged command
它的原理是首先找到這兩個分支(即當前分支 experiment、衍合操作的目標基底分支 master) 的最近共同祖先 C2,然後對比當前分支相對於該祖先的歷次提交,提取相應的修改並存為臨時文件, 然後將當前分支指向目標基底 C3, 最後以此將之前另存為臨時文件的修改依序應用。 (譯注:寫明了 commit id,以便理解,下同)
將 C4
中的修改衍合到 C3
上。
)
現在回到 master 分支,進行一次快進合併。
git checkout master
git merge experiment
)
此時,C4
指向的快照就和 the merge example 中 C5
指向的快照一模一樣了。 這兩種整合方法的最終結果沒有任何區別,但是衍合使得提交歷史更加整潔。 你在查看一個經過衍合的分支的歷史記錄時會發現,盡管實際的開發工作是並行的, 但它們看上去就像是串行的一樣,提交歷史是一條直線沒有分叉。
一般我們這樣做的目的是為了確保在向遠程分支推送時能保持提交歷史的整潔——例如向某個其他人維護的項目貢獻代碼時。 在這種情況下,你首先在自己的分支里進行開發,當開發完成時你需要先將你的代碼衍合到 origin/master 上,然後再向主項目提交修改。 這樣的話,該項目的維護者就不再需要進行整合工作,只需要快進合併便可。
請注意,無論是通過衍合,還是通過三方合併,整合的最終結果所指向的快照始終是一樣的,只不過提交歷史不同罷了。 衍合是將一系列提交按照原有次序依次應用到另一分支上,而合併是把最終結果合在一起。
更有趣的衍合例子
在對兩個分支進行衍合時,所生成的“重放”並不一定要在目標分支上應用,你也可以指定另外的一個分支進行應用。 就像 從一個主題分支里再分出一個主題分支的提交歷史 中的例子那樣。 你創建了一個主題分支 server,為服務端添加了一些功能,提交了 C3 和 C4。 然後從 C3 上創建了主題分支 client,為客戶端添加了一些功能,提交了 C8 和 C9。 最後,你回到 server 分支,又提交了 C10。
從一個主題分支里再 分出一個主題分支的提交歷史。
)
假設你希望將 client 中的修改合併到主分支並發布,但暫時並不想合併 server 中的修改, 因為它們還需要經過更全面的測試。這時,你就可以使用 git rebase 命令的 --onto 選項, 選中在 client 分支里但不在 server 分支里的修改(即 C8 和 C9),將它們在 master 分支上重放:
git rebase --onto master server client
以上命令的意思是:“取出 client 分支,找出它從 server 分支分歧之後的補丁, 然後把這些補丁在 master 分支上重放一遍,讓 client 看起來像直接基於 master 修改一樣”。這理解起來有一點覆雜,不過效果非常酷。
截取主題分支上的另一個主題分支,然後衍合到其他分支。
)
現在可以快進合併 master 分支了。(如圖 快進合併 master 分支,使之包含來自 client 分支的修改):
git checkout master
git merge client
快進合併 master
分支,使之包含來自 client
分支的修改。
)
接下來你決定將 server 分支中的修改也整合進來。 使用 git rebase <basebranch> <topicbranch>
命令可以直接將主題分支 (即本例中的 server)衍合到目標分支(即 master)上。 這樣做能省去你先切換到 server 分支,再對其執行衍合命令的多個步驟。
git rebase master server
如圖 將 server 中的修改衍合到 master 上 所示,server 中的代碼被“續”到了 master 後面。
將 server
中的修改衍合到 master
上。
)
然後就可以快進合併主分支 master 了:
git checkout master
git merge server
至此,client 和 server 分支中的修改都已經整合到主分支里了, 你可以刪除這兩個分支,最終提交歷史會變成圖 最終的提交歷史 中的樣子:
git branch -d client
git branch -d server
)
衍合的風險
呃,奇妙的衍合也並非完美無缺,要用它得遵守一條準則:如果提交存在於你的倉庫之外,而別人可能基於這些提交進行開發,那麽不要執行衍合。
衍合操作的實質是丟棄一些現有的提交,然後相應地新建一些內容一樣但實際上不同的提交。 如果你已經將提交推送至某個倉庫,而其他人也已經從該倉庫拉取提交並進行了後續工作,此時,如果你用 git rebase 命令重新整理了提交並再次推送,你的同伴因此將不得不再次將他們手頭的工作與你的提交進行整合,如果接下來你還要拉取並整合他們修改過的提交,事情就會變得一團糟。
讓我們來看一個在公開的倉庫上執行衍合操作所帶來的問題。 假設你從一個中央服務器克隆然後在它的基礎上進行了一些開發。 你的提交歷史如圖所示:
克隆一個倉庫,然後在它的基礎上進行了一些開發。
)
然後,某人又向中央服務器提交了一些修改,其中還包括一次合併。 你抓取了這些在遠程分支上的修改,並將其合併到你本地的開發分支,然後你的提交歷史就會變成這樣:
抓取別人的提交,合併到自己的開發分支。
)
接下來,這個人又決定把合併操作回滾,改用衍合;繼而又用 git push --force 命令覆蓋了服務器上的提交歷史。 之後你從服務器抓取更新,會發現多出來一些新的提交。
有人推送了經過衍合的提交,並丟棄了你的本地開發所基於的一些提交。
)
結果就是你們兩人的處境都十分尷尬。 如果你執行 git pull 命令,你將合併來自兩條提交歷史的內容,生成一個新的合併提交,最終倉庫會如圖所示:
你將相同的內容又合併了一次,生成了一個新的提交。
)
此時如果你執行 git log 命令,你會發現有兩個提交的作者、日期、日志居然是一樣的,這會令人感到混亂。 此外,如果你將這一堆又推送到服務器上,你實際上是將那些已經被衍合拋棄的提交又找了回來,這會令人感到更加混亂。 很明顯對方並不想在提交歷史中看到 C4 和 C6,因為之前就是他把這兩個提交通過衍合丟棄的。
此時如果你執行 git log 命令,你會發現有兩個提交的作者、日期、日志居然是一樣的,這會令人感到混亂。 此外,如果你將這一堆又推送到服務器上,你實際上是將那些已經被衍合拋棄的提交又找了回來,這會令人感到更加混亂。 很明顯對方並不想在提交歷史中看到 C4 和 C6,因為之前就是他把這兩個提交通過衍合丟棄的。
用衍合解決衍合
如果你 真的 遭遇了類似的處境,Git 還有一些高級魔法可以幫到你。 如果團隊中的某人強制推送並覆蓋 了一些你所基於的提交,你需要做的就是檢查你做了哪些修改,以及他們覆蓋了哪些修改。
實際上,Git 除了對整個提交計算 SHA-1 校驗和以外,也對本次提交所引入的修改計算了校驗和——即 “patch-id”。
如果你拉取被覆蓋過的更新並將你手頭的工作基於此進行衍合的話,一般情況下 Git 都能成功分辨出哪些是你的修改,並把它們應用到新分支上。
舉個例子,如果遇到前面提到的 有人推送了經過衍合的提交,並丟棄了你的本地開發所基於的一些提交 那種情境,如果我們不是執行合併,而是執行 git rebase teamone/master, Git 將會:
檢查哪些提交是我們的分支上獨有的(C2,C3,C4,C6,C7)
檢查其中哪些提交不是合併操作的結果(C2,C3,C4)
檢查哪些提交在對方覆蓋更新時並沒有被納入目標分支(只有 C2 和 C3,因為 C4 其實就是 C4')
把查到的這些提交應用在 teamone/master 上面
從而我們將得到與 你將相同的內容又合併了一次,生成了一個新的提交 中不同的結果,如圖 在一個被衍合然後強制推送的分支上再次執行衍合 所示。
在一個被衍合然後強制推送的分支上再次執行衍合。
)
要想上述方案有效,還需要對方在衍合時確保 C4' 和 C4 是幾乎一樣的。 否則衍合操作將無法識別,並新建另一個類似 C4 的補丁(而這個補丁很可能無法整潔的整合入歷史,因為補丁中的修改已經存在於某個地方了)。
在本例中另一種簡單的方法是使用 git pull --rebase 命令而不是直接 git pull。 又或者你可以自己手動完成這個過程,先 git fetch,再 git rebase teamone/master。
如果你習慣使用 git pull ,同時又希望默認使用選項 --rebase,你可以執行這條語句 git config --global pull.rebase true 來更改 pull.rebase 的默認配置。
如果你只對不會離開你電腦的提交執行衍合,那就不會有事。 如果你對已經推送過的提交執行衍合,但別人沒有基於它的提交,那麽也不會有事。 如果你對已經推送至共用倉庫的提交上執行衍合命令,並因此丟失了一些別人的開發所基於的提交, 那你就有大麻煩了,你的同事也會因此鄙視你。
如果你或你的同事在某些情形下決意要這麽做,請一定要通知每個人執行 git pull --rebase 命令,這樣盡管不能避免傷痛,但能有所緩解。
衍合 vs. 合併
至此,你已在實戰中學習了衍合和合併的用法,你一定會想問,到底哪種方式更好。 在回答這個問題之前,讓我們退後一步,想討論一下提交歷史到底意味著什麽。
有一種觀點認為,倉庫的提交歷史即是 記錄實際發生過什麽。 它是針對歷史的文檔,本身就有價值,不能亂改。 從這個角度看來,改變提交歷史是一種褻瀆,你使用 謊言 掩蓋了實際發生過的事情。 如果由合併產生的提交歷史是一團糟怎麽辦? 既然事實就是如此,那麽這些痕跡就應該被保留下來,讓後人能夠查閱。
另一種觀點則正好相反,他們認為提交歷史是 項目過程中發生的事。 沒人會出版一本書的第一版草稿,軟件維護手冊也是需要反覆修訂才能方便使用。 持這一觀點的人會使用 rebase 及 filter-branch 等工具來編寫故事,怎麽方便後來的讀者就怎麽寫。
現在,讓我們回到之前的問題上來,到底合併 還是衍合好?希望你能明白,這並沒有一個簡單的答案。 Git 是一個非常強大的工具,它允許你對提交歷史做許多事情,但每個團隊、每個項目對此的需求並不相同。 既然你已經分別學習了兩者的用法,相信你能夠根據實際情況作出明智的選擇。
總的原則是,只對尚未推送或分享給別人的本地修改執行衍合操作清理歷史, 從不對已推送至別處的提交執行衍合操作,這樣,你才能享受到兩種方式帶來的便利。